<?php

/**
 * Helper functions for the compiler
 *
 * @author    Mon Zafra <monzee@gmail.com>
 * @package   Yano
 * @copyright (c)2009 Mon Zafra
 * @license   New BSD
 */
class Yano_Template_Parser
{
    protected $_escapeChar = '\\';
    protected $_delimiters = array('{}', '[]', '()');
    protected $_quotes = '\'"';

    /**
     * Array of arrays or 2-char string of enclosing delimiters
     * @param array $delimiters
     * @return Yano_Template_Parser
     */
    public function setDelimiters(array $delimiters)
    {
        $this->_delimiters = $delimiters;
        return $this;
    }

    /**
     * Character used to denote that the quote (and delimiter?) should be taken literally
     * @param string $escapeChar The escape character
     * @return Yano_Template_Parser
     */
    public function setEscapeChar($escapeChar)
    {
        $this->_escapeChar = $escapeChar;
        return $this;
    }

    /**
     * Set quotation marks
     * @param string|array $quotes Array of quotes or string of quote chars
     * @return Yano_Template_Parser
     */
    public function setQuotes($quotes)
    {
        $this->_quotes = $quotes;
        return $this;
    }

    /**
     * Finds the position of a closing delimiter in a string
     * @param string       $haystack
     * @param int          $start      Position to start searching
     * @param string|array $delimiters Open and close characters/phrases
     * @return int|bool Returns false if not found
     */
    public function findCloseDelimiter(&$haystack, $start=0, $delimiters='{}')
    {
        $escapeChar = $this->_escapeChar;
        $open = $delimiters[0];
        $close = !isset($delimiters[1]) ? $delimiters[0] : $delimiters[1];
        $escape = $open == $close;
        $offset = $start;
        $openCount = 0;
        $quoteless = '';
        $closePos = $this->findUnquotedDelimiter($haystack, $close, $offset, $quoteless);
        if (false !== $closePos) {
            $openCount += substr_count(substr($quoteless, 0, $closePos), $open, $offset + 1);
            while ($openCount > 0) {
                $lastPos = $closePos;
                $closePos = $this->findUnquotedDelimiter($haystack, $close, $lastPos + 1, $quoteless);
                if (false === $closePos) {
                    $closePos = $lastPos;
                    break;
                }
                $openCount += substr_count(substr($quoteless, 0, $closePos), $open, $lastPos) - 1;
            }
        }
        return $closePos;
    }

    /**
     * Find a character that is not surrounded by quotes
     *
     * Creates a map of the haystack with the quoted areas blanked out, which
     * can be reused in subsequent searches on the same string
     * @param string $haystack
     * @param string $needle
     * @param int    $offset
     * @param string $quoteMap
     * @return int
     */
    public function findUnquotedDelimiter($haystack, $needle, $offset=0, &$quoteMap='')
    {
        $quotes = $this->_quotes;
        $escapeChar = $this->_escapeChar;
        $quoteStart = 0;
        $quoteEnd = 0;
        $useQuoteMap = !empty($quoteMap);
        // project quote map into haystack
        $haystack = $useQuoteMap ? $quoteMap . substr($haystack, strlen($quoteMap)) : $haystack;
        $quoteMap = substr($haystack, 0, $offset);
        // repeat until there's no more $needle or the last identified quoted space
        // overshoots the delimiter
        while (false !== ($delimPos = strpos($haystack, $needle, $offset)) &&
            $quoteEnd < $delimPos && $offset < strlen($haystack)
        ) {
            // find quotes starting from $offset
            $quotesPos = array();
            for ($i=0, $len=strlen($quotes); $i < $len; $i++) {
                $quote = $quotes[$i];
                $pos = strpos($haystack, $quote, $offset);
                if (false !== $pos) {
                    $quotesPos[$i] = $pos;
                }
            }
            // no quotes found
            if (empty($quotesPos)) {
                $quoteMap = substr($haystack, 0, $delimPos + 1);
                break;
            }
            // get the first quote found, look for its closing counterpart
            $quoteStart = min($quotesPos);
            $index = array_keys($quotesPos, $quoteStart);
            $quote = $quotes[$index[0]];
            // close quote should not be preceded by an odd number of escape characters
            $quoteEnd = false;
            $pattern = '/(' . preg_quote($escapeChar) . '*)' . preg_quote($quote) . '/';
            $numMatches = preg_match_all($pattern, $haystack, $matches,
                PREG_PATTERN_ORDER|PREG_OFFSET_CAPTURE, $quoteStart + 1);
            if ($numMatches) {
                $matches = $matches[1];
                foreach ($matches as $match) {
                    $len = strlen($match[0]);
                    if ($len % 2 == 0) {
                        $quoteEnd = $match[1] + $len;
                        break;
                    }
                }
            }
            // last found quote is unclosed: return false if the delimiter
            // is found after the last open quote, otherwise return the delimiter pos
            if (false === $quoteEnd) {
                $quoteEnd = strlen($haystack) - 1;
                $delimPos = $quoteStart > $delimPos ? $delimPos : false;
            }
            // cache quoted space so it can be used in subsequent searches
            $quoteMap .= substr($haystack, $offset, $quoteStart - $offset)
                      . str_repeat('_', $quoteEnd - $quoteStart + 1);
            // at the next iteration, start searching from the end of the close quote
            $offset = $quoteEnd + 1;
            if ($quoteStart > $delimPos) {
                break;
            }
        }
		return $delimPos;
    }

    /**
     * Same as explode, but only on 'bare' delimiters
     * @param string $delimiter
     * @param string $base
     * @param int    $limit     Max number of elements; 0 = no max
     * @param bool   $trim      Remove leading & trailing whitespace in elements?
     * @param bool   $allowNull Allow null elements
     * @return array
     */
    public function splitUnenclosed($delimiter, $base, $limit = 0, $trim = true, $allowNull = false)
    {
        $lastPos = 0;
        $ret = array();
        $lastUnenclosed = 0;
        $count = 0;
        $limited = $limit > 0;
        while ((!$limited || ++$count < $limit) &&
            false !== $pos = strpos($base, $delimiter, $lastPos)
        ) {
            if (!$this->isEnclosed($base, $pos)) {
                $elem = substr($base, $lastUnenclosed, $pos - $lastUnenclosed);
                $elem = $trim ? trim($elem) : $elem;
                if ('' != $elem || $allowNull) {
                    $ret[] = $elem;
                }
                $lastUnenclosed = $pos + 1;
            }
            $lastPos = $pos + 1;
        }
        $elem = substr($base, $lastUnenclosed);
        $ret[] = $trim ? trim($elem) : $elem;
		return $ret;
    }

    /**
     * Same function as strpos, but for 'bare' needles
     * @param string $haystack
     * @param string $needle
     * @param int    $offset
     * @return int
     */
    public function findUnenclosed(&$haystack, $needle, $offset=0)
    {
        while (false !== $pos = strpos($haystack, $needle, $offset)) {
            if ($this->isEnclosed($haystack, $pos)) {
                $offset = $pos + 1;
            } else {
                break;
            }
        }
        return $pos;
    }

    /**
     * Determines whether a position in a string is bounded by a delimiter pair
     *
     * Does not perform a look-ahead; only look-behind on opening delimiters.
     * @param string $haystack
     * @param int    $needlePos
     * @param array  $delimiters
     * @return bool
     */
    public function isEnclosed(&$haystack, $needlePos, $delimiters=array(), $delimMode=Yano_Template_Engine::DELIM_COMPLEMENT)
    {
        $quotes = is_array($this->_quotes) ? $this->_quotes : str_split($this->_quotes);
        $defaultDelimiters = array_merge($this->_delimiters, $quotes);
        switch (strtolower($delimMode)) {
            case Yano_Template_Engine::DELIM_COMPLEMENT:
                $delimiters = array_diff($defaultDelimiters, $delimiters);
                break;
            case Yano_Template_Engine::DELIM_UNION:
                $delimiters = array_unique(array_merge($defaultDelimiters, $delimiters));
                break;
            case Yano_Template_Engine::DELIM_REPLACE: // don't touch
            default:
                break;
        }
        $escapeChar = $this->_escapeChar;
        $behind = substr($haystack, 0, $needlePos);
        foreach ($delimiters as $pair) {
            $open = $pair[0];
            $close = !isset($pair[1]) ? $pair[0] : $pair[1];
            if ($open != $close) {
                // find the first open delimiter to the left of $needlePos
                $pos = strrpos($behind, $open);
                // if found, return true if there is no close delimiter
                // between the open delimiter and needle
                if (false !== $pos && false === strpos(substr($behind, $pos), $close)) {
                    return true;
                }
            } else {
                // delimiter is a quote mark
                // count number of delimiters to the left of needlepos
                $count = substr_count($behind, $open);
				if ($count == 0) {
					continue;
				}
                // exclude escaped
                $pattern = '/(' . preg_quote($escapeChar) . '*)['
                    . preg_quote($open) . ']/';
                $numMatches = preg_match_all($pattern, $behind, $matches);
                $numEscaped = 0;
                if ($numMatches) {
                    $matches = $matches[1];
                    foreach ($matches as $match) {
                        if (strlen($match) % 2 == 1) {
                            $numEscaped++;
                        }
                    }
                }
                $count -= $numEscaped;
                if ($count % 2 == 1) {
                    return true;
                }
            }
        }
        return false;
    }
    
}
